Как ты знаешь, регистры процессора 8088 — 16-битные. Однако при необходимости ты можешь работать через эти регистры не только с 16-битными числами, но и с числами большей разрядности: и с 32-битными, и даже более крупными. В этой статье я сначала расскажу как, а затем мы нарисуем знаменитый фрактал — множество Мандельброта.
 

Простейшие операции над 32-битными числами

Сразу возникает вопрос: если регистры у нас 16-битные, то как с их помощью обрабатывать 32-битные числа? Ответ очевиден: мы просто будем задавать каждое число не одним регистром, а сразу двумя.

Только нам надо сначала определиться, какими регистрами и как мы будем для этого пользоваться. Давай не станем изобретать велосипед, а поищем подсказки в самом процессоре 8088.

У 8088 есть инструкция mul, которая умножает AX на 16-битный регистр и кладет результат в DX:AX. Также у него есть инструкция div, которая делит DX:AX на 16-битный регистр; результат попадает в регистр AX, а остаток — в DX. Еще у 8088 есть инструкция cwd. Она конвертирует знаковое 16-битное число из регистра AX в 32-битное число DX:AX.

Давай и мы, по примеру этих трех инструкций, тоже будем хранить 32-битные числа в DX:AX (в DX старшее слово, в AX — младшее). Но чтобы выполнять арифметические операции, нам нужно еще одно 32-битное число. Его, по аналогии с первым, будем хранить в CX:BX (в CX старшее слово, в BX — младшее).

Ну вот, мы с тобой условились, где и как хранить 32-битные числа. Теперь давай реализуем для них операцию сложения и операцию вычитания. Для этого нам пригодятся инструкции adc и sbb. Вот так выглядит сложение.

Удивлен, что операция сложения у нас заняла всего две инструкции? Сейчас объясню, что тут происходит. Дело в том, что, когда ты выполняешь инструкцию add, она не только складывает два числа, но и изменяет флаг переноса. Когда результат операции сложения не умещается в сдвоенный байт, инструкция add помещает старшую цифру результата (это всегда единица) во флаг переноса.

Инструкция adc dx, cx выполняет вот такую операцию: DX = DX + CX + перенос, то есть прибавляет к итоговому результату то значение, которое хранится во флаге переноса.

Теперь давай реализуем вычитание 32-битных чисел по такому же принципу.

Что тут происходит? Инструкция sub вычитает из одного числа другое, а еще изменяет флаг переноса. Когда операция вычитания делает «заем» из соседнего разряда, флаг переноса устанавливается в единицу.

Инструкция sbb dx, cx выполняет вот такую операцию: DX = DX – CX – перенос, то есть вычитает из итогового результата то значение, которое хранится во флаге переноса.

Мы с тобой успешно реализовали операции сложения и вычитания. Теперь давай реализуем логическое инвертирование и арифметическое инвертирование.

Чтобы сделать логическое инвертирование 32-битного числа (not), нам надо просто переключить все биты числа на противоположные.

Если требуется выполнить арифметическое инвертирование (neg), то есть поменять знак числа, нужно сделать то же самое, но только прибавить единицу к результату.

 

Реализуем операцию умножения двух 32-битных чисел

А теперь давай реализуем операцию умножения. Это уже будет посложнее. Здесь нужно вспомнить то, что ты изучал в начальной школе.

Ты же, надеюсь, еще не разучился умножать числа в столбик? На всякий случай напомню, что мы тут делаем.

Поочередно, справа налево, умножаем каждую цифру множимого на вторую цифру множителя. Так у нас получается первая строчка промежуточного результата. Затем умножаем каждую цифру множимого на первую цифру множителя. Так у нас получается вторая строчка промежуточного результата. Затем сдвигаем вторую строчку на один разряд влево и суммируем два промежуточных результата.

Получается, чтобы перемножить два двузначных числа, нам нужно выполнить четыре операции умножения. А если надо перемножить числа большей разрядности, то операций умножения потребуется еще больше.

Но это если «в роли цифры» у нас выступают цифры от 0 до 9. Однако, зная, что у процессора 8088 есть инструкция для умножения 16-битных чисел, мы для удобства можем в своем алгоритме умножения «назначить на роль цифры» сдвоенный байт. То есть будем считать значения вроде 0x6725 и 0x1561 не числами, а цифрами!

Почему это удобнее? Потому что для умножения двух 32-битных чисел (по две 16-битные цифры на каждое) нам понадобится всего четыре инструкции умножения. Тогда умножение двух 32-битных чисел можно будет реализовать вот так.

Умножение, конечно, выглядит сложновато по сравнению со сложением и вычитанием. Но не переживай, сейчас все объясню. Здесь весь алгоритм разделен на четыре операции умножения: по одной на каждое 16-битное слово. Точно так же, как на рисунке с умножением в столбик.

Кстати, если такой же алгоритм реализовывать на 32-битном процессоре, его можно расширить до операций над 64-битными числами, а если на 64-битном процессоре, то над 128-битными числами.

Но давай вернемся к нашему 16-битному алгоритму. Обрати внимание, здесь под результат отводится только 48 бит. А это значит, что если умножить, допустим, 0xFFFFFFFF на 0xFFFFFFFF, то старшие два байта потеряются. Чтобы они не терялись, нужно 64 бита, а не 48. Можешь в качестве домашнего задания доделать функцию — чтобы она возвращала 64-битный результат.

 

Знакомимся с графическим видеорежимом

Мы реализовали пять операций для работы с 32-битными числами: сложение, вычитание, логическую инверсию, арифметическую инверсию, умножение. Давай скорее применим их на какой-нибудь интересной задаче!

Создадим программу, которая с их помощью рисует изображение. Точнее, две программы: простенькую и сложную. Простенькая программа будет рисовать на экране цветовую палитру. Ее мы напишем, чтобы разобраться, как работать с графическим режимом. А потом примемся за программу посложнее, в которой для рисования используется насыщенная математика.

До сих пор мы работали только в текстовом режиме (80 × 25), а сейчас поработаем в графическом режиме, с разрешением 320 на 200 пикселей и с 256 цветами. В этом режиме видеопамять экрана расположена по адресу 0xA0000–0xAFFFF. Как к ней получить доступ? Просто загрузи в регистры DS и ES значение 0xA000.

А теперь зададим положение текущего пикселя на экране.

Дальше вычисляем адрес очередного пикселя для рисования.

Теперь у нас есть адрес текущего пикселя (0–63 999, или 0x0000–0xF9FF).

Обрати внимание на инструкцию xchg. Она у нас здесь меняет значение двух регистров: AX и DI. Но то значение, которое теперь записано в регистре DI, нам не нужно. Нам надо только поместить в DI значение AX. Зачем же тогда нам xchg? Почему бы не написать простой mov? Потому что xchg позволяет сэкономить один байт. Инструкция mov di, ax использует два байта, а xchg — один. xchg всегда экономит нам байт, когда один из операндов — это AX, а другой — еще какой-то 16-битный регистр.

Теперь вычислим цвет текущего пикселя и выведем его на экран. Причем вычислим так, чтобы цвета выводились квадратиками 16 × 16 пикселей.

Что тут делаем? Берем по четыре бита от двух координат и на их основе высчитываем цвет в диапазоне от 0 до 255 (два раза по четыре бита — это восемь бит; вот и получается нужный диапазон). Вычислив текущий цвет, выводим пиксель на экран. Выводим по адресу, на который указывает регистр DI. Этот адрес мы вычислили на предыдущем шаге.

А дальше просто закручиваем двойной цикл, который пробегает по всем столбцам и строкам и рисует пиксели нужного цвета, чтобы в итоге получилась палитра.

Что тут делаем? Ну, во-первых, делаем цикл. А после того, как все пиксели нарисованы, ждем, когда пользователь нажмет клавишу. После этого восстанавливаем текстовый режим и возвращаемся в командную строку.

При помощи этой программы при желании можно выбирать цвет. Поскольку они не подписаны, алгоритм такой: отсчитай квадратики до нужной строки сверху вниз в шестнадцатеричной системе счисления и начиная с 0 — так у тебя получится левая цифра цвета. Затем отсчитай слева направо до нужного столбца — так ты получишь правую цифру цвета.

Например, левый верхний черный квадратик нарисован цветом 0x00, а верхний правый квадратик белого цвета — 0x0F.

 

Рисуем множество Мандельброта: подготовительные шаги

Теперь, когда ты узнал, как работать с большими 32-битными числами и как рисовать пиксели в графическом режиме, давай напишем программу посложнее — будем рисовать множество Мандельброта.

Вот алгоритм, который мы с тобой сейчас реализуем.

Или, если ты знаком с С, вот аналогичный сишный код.

Давай реализуем этот алгоритм на ассемблере.

Обрати внимание: fractX и fractY — это 32-битные дробные числа. В них первые 24 байта хранят целую часть числа, а оставшиеся восемь — дробную. Например, 1.0 сохраняется как 0x00000100 (или 256 в десятичной системе счисления).

Имей в виду, что произведение двух таких дробных чисел удваивает количество битов, отведенных под дробную часть, и поэтому результат надо обязательно поделить на 256. Например, 0x0100 * 0x0100 = 0x010000. Но после деления на 256 результат будет выглядеть корректно: 0x0100 (1.0 * 1.0 = 1.0).

На этом все предварительные замечания сделаны. Начинаем писать программу.

Что мы тут делаем? Директивой cpu просим компилятор проконтролировать, что мы используем инструкции только из набора 8086 (или его собрата — 8088).

Затем задаем адреса для нужных нам переменных. Обрати внимание, что переменные fractX и fractY занимают по четыре байта, а не по два, как остальные. А промежуточная переменная dest48bit вообще занимает шесть. Она нам нужна для реализации алгоритма умножения 32-битных чисел.

Затем переходим в графический режим 320 × 200 × 256 и нацеливаем сегментные регистры на видеопамять экрана.

Снова, как и в случае с программой-палитрой, начинаем рисовать от правого нижнего пикселя экрана. Его координаты (xScreen, yScreen) такие: (319, 199). Смотри не спутай xScreen и yScreen с fractX и fractY, которые нужны не для рисования графики, а для сопутствующих математических вычислений.

Теперь реализуем первый шаг алгоритма: зададим начальные значения для fractX и fractY, а также для счетчика итераций.

Обрати внимание: для инициализации переменных fractX и fractY мы используем по две инструкции mov, потому что эти переменные 32-битные.

 

Краткий ликбез по работе со стеком

Начальные значения заданы, теперь проверим условие выхода.

Что мы тут делаем? Во-первых, активно используем стек. До сих пор я не рассказывал, в каком порядке данные хранятся в стеке, но теперь пришло время разобраться в этом.

Начну с объяснения на пальцах, а уже потом познакомлю тебя с формальным определением.

Представь, что ты кладешь тарелку на стопку тарелок. Как раз так и работает push. А теперь представь, что ты снимаешь верхнюю тарелку со стопки тарелок. Так работает инструкция pop.

Такой способ хранения данных называется LIFO (last in first out, пришел последним — уйдет первым). Как этот принцип отражается на нашей программе? Мы кладем на стек сначала DX, потом AX (результат операции x^2). Потом первая инструкция pop bx снимает сохраненное значение AX и кладет его в BX, а вторая инструкция pop bx снимает сохраненное значение DX.

Более сложное и точное объяснение звучит так. Инструкции push, pop, call и ret отталкиваются от регистра SP (stack pointer — указатель стека). SP указывает на текущую ячейку в сегменте стека. Адрес сегмента стека хранится в регистре SS.

Всякий раз, когда ты кладешь данные на стек, используя инструкцию push, регистр SP уменьшается на 2, а данные (сдвоенный байт) записываются в память по адресу, на который указывает регистр SP.

Каждый раз, когда ты снимаешь данные со стека, используя функцию pop, данные (сдвоенный байт) считываются с адреса, который указан в регистре SP, а SP увеличивается на 2.

 

Рисуем множество Мандельброта: делаем вычисления

Идем дальше. Пишем тело основного цикла. Сначала вычисляем значение t = x^2 – y^2 + i * x (смотри чуть выше картинку с алгоритмом).

Обрати внимание, как я тут прибавляю к xScreen 32-битное значение. Поскольку инструкция add работает с 16-битными числами, я добавляю инструкцию adc dx, 0 — для завершения операции сложения.

Теперь вычислим значение y = 2x * y + i * y (смотри чуть выше картинку с алгоритмом).

Обрати внимание на комбинацию двух операций: shl ax, 1 и rcl dx, 1. Вместе они реализуют 32-битное умножение на два (через сдвиг влево). Первая инструкция сдвигает AX на один бит влево и подставляет справа ноль. После выполнения этой инструкции тот бит, который вытеснился слева, попадает во флаг переноса. Вторая инструкция (rcl) сдвигает DX влево и подставляет на освободившееся справа место бит из флага переноса.

Дальше готовимся к следующей итерации цикла.

Что мы тут делаем? Вначале снимаем со стека какое-то значение. Помнишь, что там хранится? Там сейчас хранится новое значение для переменной fractX! Если не понимаешь, как оно там оказалось, вернись назад и найди строку sub ax, 480. После этой инструкции мы положили на стек 32-битное значение. Вот его мы сейчас и снимаем со стека.

Обрати внимание, мы не можем записать это значение сразу в fractX, потому что предыдущее значение fractX нам еще нужно — для вычисления координаты fractX.

После этого увеличиваем счетчик итераций (CX) и сравниваем его с числом 100. Когда он достигает ста, выходим из цикла, а иначе переходим к следующей итерации (прыгаем на @@nextIter).

А теперь самое интересное! До сих пор мы только делали математические расчеты и ничего не рисовали на экране. Пришло время порисовать. Рисуем!

Что мы тут делаем? Почти то же самое, что и в программе-палитре. За исключением того, что к значению CL (0–99) добавляем число 32 — чтобы сразу попасть на цвета радуги, а не на серые.

С множеством Мандельброта закончили! Но мы еще не написали подпрограмму для 32-битного умножения. По сравнению с тем вариантом, который мы разбирали вначале, здесь есть несколько доработок. Подпрограмма теперь принимает дробные числа со знаками.

 

Усовершенствованная подпрограмма умножения 32-битных чисел

Вот ее листинг с комментариями.

Рекомендую поэкспериментировать со значениями смещения, которые мы задали для центровки изображения (480 и 300). И попробуй удалить дублированные инструкции add для yScreen и xScreen, а еще поменять количество итераций. Сейчас установлено значение 100. Можешь попробовать задать значение больше или меньше.

 

Выводы

Поздравляю, ты сделал еще один шаг в освоении асма! Ты научился оперировать большими числами, выполнять сложные математические расчеты и работать в графическом режиме. Теперь ты можешь писать на ассемблере довольно-таки сложные программы. До встречи на следующем уроке!

Оцени статью:

Что тебе понравилось больше всего?
Что тебе не понравилось больше всего?